Chapter 13: 13. Object-Oriented Programming with Python
From book Python Programming (Problem solving, Packages and Libraries) published by McGraw Hill Education (India) Private limited. By:

  • Anurag Gupta
  • G. P. Biswas

Note the following:-

  1. This html document is meant as an accompaniment to Chapter XX XX .
  2. The document contains scripts executed on IDLE as well as on Jupyter notebook.
  3. The scripts executed on Jupyter can be directly copied and run into a Jupyter notebook or some other IDE (Like Pycharm or Eclipse with PyDev or Visual studio).
  4. However the scripts on IDLE also contain the >>> symbol and therefore cannot be directly executed. If you want to execute them on IDLE or Jupyter, you need to manually remove the >>> symbol.
  5. Wherever needed some background material from the book is also included to help you better understand the scripts
  6. The topic numbers given on each paragraph match the topic numbers of the book, so you can easily identify the topics and corresponding scripts.
  7. In some of the scripts, the file paths give are that of the author's computer. You need to replace them with file paths of your own computer.
  8. At some places, to improve readability, page numbers of the book are indicated in green font like:- See Page 181 of the book
  9. This document was first created as a Jupyter Notebook as combination of Markdown and code cells (extension .ipynb) and then downloaded as html. If someone wants to "modify" or "extend' this document, you may ask for the original .ipynb file by sending me an e-mail at:- 999.anuraggupta@gmail.com

13.2.7. Concept of operator overloading
See Page 316 of the book
There is a concept in object-oriented programming which is called operator overloading.
It is best explained by an example: Suppose you have two variables $x$ and $y$ and you write a statement: $z = x + y$. Here $x$ and $y$ are the operands and $+$ is the operator. Suppose $x = 3$ and $y = 4$, then both are of type int and the + operator will add these two int values and assign the result to $z$, i.e., 7. But suppose you have $x =\ ’3’$ and $y = \ '4'$, then what? Should the result be ‘addition’ of numbers to give $7$? Or should it be joining (concatenation) of the two digits (which here are strings) leading to an answer of $34$? Operator for addition i.e. ($+$) ‘sees’ the operands on both sides of the operator (i.e., $x$ and $y$ here) and decides accordingly what to do. If both operands are integers it ‘adds’ them. If both operands are strings it ‘concatenates’ them. So operator ‘$+$’ behaves differently depending on the operands. This is operator overloading. Do however note that if the two operands are of different types for the $+$ operator, it will first try to “cast” one of the operands to the “type” of other and then do the operation. But if casting is not possible then it will generate error. It will be clear from the following code:

# ---ON IDLE---
#    Multiple assignments on same line permitted as follows
>>> x,y = 3,4# Due to this assignment of 3 and 4, both x and y are of type int
>>> z = x+y    #x and y are ‘added’ like number in maths
>>>print(z)
7
>>> x,y = '3','4' #  However here both x and y are of type string (str)
>>> z = x+y  # Here x and y are not ‘added’ like integers but rather concatenated
>>>print(z)
34
>>>

13.2.8. Short note on function overloading
Function overloading is not present in Python. Why? Because function overloading is also called “compile time polymorphism” and since Python is not a compiled language but an “interpreted” language, there is no function overloading in Python. However, “function overloading” is available in compiled languages like Java and C++. It is a very simple concept:

  • It says that in the same piece of code you may have functions with the same name but different numbers of parameters.
  • So in Java it is possible to have same function name with different numbers (or data types) of variables and the function will act differently depending on the number or type of arguments passed.
  • This is not possible in Python. If you use the same function name twice in Python, the second definition will prevail.

Following code shows it but read this after you have understood how to write class definition in Python:

In [1]:
class A:
    def f1(self): # First definition of f1()
        print("1st implementation")
    def f1(self, var = '2nd implementation'): # 2nd implementation of f1()
        print(var)

a = A() # Create object of class A
a.f1() # The second definition of f1() will overwrite the first always
2nd implementation

13.2.9. Creating a simple class and simple objects
Note: As per the Python style guide, class names should always begin with a capital letter. In Python, the syntax for writing code of a class in pseudo-code is:

class ClassName:
    <statement-1>
    .
    .
    .
    <statement-N>

You can create a Person class as follows:

In [2]:
class Person:
    name = "XXX"
    age = 0
    sex = "Male"

# Create 2 objects p1 and p2 of Person class
p1 = Person()
p2 = Person()
print('p1 name->',p1.name, 'p2 age->',p2.age)
p1.name = "Anil"
p2.age = 20
print('p1 name->',p1.name, 'p2 age->',p2.age)
p1 name-> XXX p2 age-> 0
p1 name-> Anil p2 age-> 20

13.3. OOP concepts related specifically to Python
13.3.1. A class which has an __init__() method
There is a serious problem with the Person class created above. All objects of type Person have same name ‘xxx’, age 0 and sex ‘Male’.
Suppose you wanted to be able to create objects of type Person with name, age and sex as per your own choice, then how can you do this?
The answer to this problem in programming languages lies in the concept of a ‘constructor’. All modern object-oriented languages like C++ and Java have ‘constructors’ to initialize the instance of an object as per parameters supplied by the creator of the object.
What is a constructor to a class? When you create an object of a class type, you may at times want to initialize it with certain values. For example, you may want to create an object say p1 of class type Person, with name, sex and age supplied by the creator of the object. To do this, you must define a constructor in the class In Python, this is done by the __init__() method.

  • An __init__() method has double under scores on both sides.
  • So it is a “double under” or popularly known as “dunder” method.
  • There are many such methods in Python.
  • The other important concept is the concept of ‘self’. When you create an object by using as shown above p1 = Person(), then you can access the properties of the object using dot notation like p1.name.
  • However “inside” the class definition how do you access the variables of the class? This is done using the key word self.

The use of ‘self’ as inside a class definition is shown in the following code:

In [3]:
class Person:
    def __init__(self, name, sex, age):
        self.myName = name
        self.mySex = sex
        self.myAge = age

# Create objects p1 and p2
p1 = Person("Sunil Kumar", 'Male', 19)
p2 = Person("Anita", "Female", 18)
print("p1 is ", p1.myName,'sex ', p1.mySex, 'age ',p1.myAge)
print("p2 is ", p2.myName,'sex ', p2.mySex, 'age ',p2.myAge)
p1 is  Sunil Kumar sex  Male age  19
p2 is  Anita sex  Female age  18

In many programming examples, often the local variables passed to the __init__() functions are same as the attributes of the object.
In the script given below, the attributes of an object of Person class are name, sex and age which is same as parameters passed to the __init__() function.
You should not get confused by a statement like: self.name = name. It simply means that the parameter name passed to the constructor__init__() has been assigned to the name attribute of the Person class. So there are two names: The first name is the parameter passed to the __init__() function and the second name is the attribute of an object of Person class. The first name will exist only within the __init__() function, but the second name will be there as long as object of type Person exists.
For the script below, see Page 320 of the book

In [4]:
class Person:
    def __init__(self, name, sex, age):# pass name, sex, age parameters to init
        self.name = name # LHS variable name is attribute of Person class
        self.sex = sex
        self.age = age

p1 = Person('Sunil Kumar', 'Male', 19)
print('p1 name-> ',p1.name)
p1 name->  Sunil Kumar

13.3.2. A class which has attributes, __init__() and also default values for __init__().
It is also possible to provide default values to an object created by providing these values to the __init__() function.
Why do you need default values? One reason could be that most of the objects being created are having a particular value for an attribute.
For example suppose the above Person class was being used to create objects who were mostly 20 years. Then you could give the default value of 20 to the age attribute. This is shown in the following code:

In [5]:
class Person:
    def __init__(self, name, sex, age = 20):
        self.name = name
        self.sex = sex
        self.age = age

anju = Person('Anju Kumari', 'Female')
print('Anju age-> ',anju.age) # default 20 taken for age attribute of anju object
sunita = Person('Sunita Kumari', 'Female', 25) #3rd param given.Override default
print('Sunita Age-> ',sunita.age) # Default value of 20 overridden by 25
Anju age->  20
Sunita Age->  25

Note that the default arguments can be provided to the class constructor in two ways. In the first way, it is provided by position. Here you can provide the default arguments to the parameters starting from the right. In the above example, you provided default of 20 to age which is the right most argument to the __init__(). You could have provided default to the next argument from right also, i.e., sex as follows:

In [6]:
class Person:
    def __init__(self, name, sex = 'Female', age = 20):
        self.name = name
        self.sex = sex
        self.age = age

anita = Person('Anita Kumari')
print('Anita is-> ',anita.sex)
Anita is->  Female

However, you cannot assign a default parameter to the second parameter of the __init__() while not providing a default to the third. This is shown as follows (There will be an error):

In [7]:
class Person:
    def __init__(self, name, sex = 'Female', age):
        self.name = name
        self.sex = sex
        self.age = age

p = Person('Sunita',50)
  File "<ipython-input-7-b2159764c8a0>", line 2
    def __init__(self, name, sex = 'Female', age):
                ^
SyntaxError: non-default argument follows default argument

However, you can change the order of providing the arguments to the class constructors by specifying the attribute name.
Hence, the following code is possible:
For script below, see Page 322 of the book

In [8]:
class Person:
    def __init__(self, name, sex = 'Female', age = 20):
        self.name = name
        self.sex = sex
        self.age = age

amit = Person(sex = 'Male', age = 22, name = 'Amit Kumar') # attrib order changed
print('Name-> ',amit.name,'Sex-> ',amit.sex, 'Age-> ',amit.age)
Name->  Amit Kumar Sex->  Male Age->  22

13.3.3. A class which has attributes as well as member functions or class methods
So far you have studied classes which have attributes and also an __init__() function. However, class in Python can also have member functions. This will become clear from an example.
Suppose you want to have two member functions of the Person class. The first will set() the city of residence of the object of Person type and the second will get() the city of residence of the Person type. This is shown as follows:

In [9]:
class Person:
    def __init__(self, name, sex = 'Female', age = 20):
        self.name = name
        self.sex = sex
        self.age = age
        self.lang = 'Hindi'

    def setL(self, lang):#Method of Person class. First parameter must be self
        self.lang = lang
    def getL(self):         # Another method of Person class
        return self.lang
# Create objects
radha = Person("Radha Kumari")
print('Before setting language-> ',radha.getL())
radha.setL("English") # Set language to English
print('After setting language-> ',radha.getL())
Before setting language->  Hindi
After setting language->  English

13.3.4. Concept of instance methods (or methods applicable to objects), static methods and class methods
As pointed out earlier, in Python, there are classes and instances of these classes called objects.
There may be a scenario where you are not interested in “instance” variables but “class variables”.
In the Person class used above, suppose the programmer wanted to know the number of objects of type Person created. This number is not relevant to a particular object, but rather to a class as a whole.
The class variables are accessed using the class name rather than the object name. This is similar to a car factory, where the individual car objects may not be interested in knowing how many cars are produced, but the factory manager might want to know the number of cars produced.
Consider the following code:

In [10]:
class Person:
    count = 0
    def __init__(self, name, sex = 'Female', age = 20):
        Person.count = Person.count +1
        self.name = name
        self.sex = sex
        self.age = age
    def numP(): # No self parameter in method numP() because it is staticmethod
        print('count->',Person.count)
    numP = staticmethod(numP) # old method of defining a static method
# create 3 objects of Person class ie anita, sunita and sunil
anita = Person('Anita')
sunita = Person('Sunita')
sunil = Person('Sunil', 'Male', 19)
Person.numP()   # Call to static method numP() of Person Class
count-> 3

13.3.5. Function decorator @staticmethod
There is another way of telling the Python interpreter that a particular method is a static method. This way is by using function decorators.
A function decorator is placed just before the def statement. It starts with a @ symbol.
For example the function decorator for declaring a method as static in Python is @staticmethod.
Note that @staticmethod function is just a function “defined inside a class”, nothing more.
You can “call” a static method without first “instantiating, i.e., creating an instance” of the class.
Hence, the example given above (In which you have used numP = staticmethod(numP), could be also written using @staticmethod as follows:

In [11]:
class Person:
    count = 0
    def __init__(self, name, sex = 'Female', age = 20):
        Person.count = Person.count +1
        self.name = name
        self.sex = sex
        self.age = age
    @staticmethod       #Function decorator
    def numP():         # No self parameter in numP() because it is staticmethod
        print('count->',Person.count)

# create 3 objects of Person class ie anita, sunita and sunil
anita = Person('Anita')
sunita = Person('Sunita')
sunil = Person('Sunil', 'Male', 19)
Person.numP()   # Call to static method numP() of Person Class
count-> 3

Another example will clarify the need for static methods. Suppose

  • You create a class Products which among other things also has a class variable tax_rate.
  • Further suppose that this tax rate is common to all objects and may be increased or decreased from time to time.
  • So the tax rate is actually not an object property but rather a class property because it is the same for all instances of Products.
  • Now suppose a new tax rate is announced and you need to change the tax rate for all products that you have.
  • This means if you need to increase/ decrease the tax rate, you need a static method which does not depend upon any object of class Products.

The following code shows this:

In [12]:
class Products:
    tax_rate = 0
    def __init__(self, name = ''):
        self.name = name
        print('tax rate for object->', Products.tax_rate)
    @staticmethod
    def add_interest(rate_increase = 0):
        Products.tax_rate = Products.tax_rate + rate_increase
        print('new tax rate->', Products.tax_rate)

# Increase tax_rate
Products.add_interest(0.5)
Products.add_interest(0.5)
# Create object of Products class
pen = Products('pen')
new tax rate-> 0.5
new tax rate-> 1.0
tax rate for object-> 1.0

13.3.6. Data hiding, mangling, pseudo-private member variables in a class
Python supports the concept of name “mangling”. The concept is very simple. Inside a class you may use a variable name which is also being used in another class. How do you differentiate between these two similar variables? Especially in case of inheritance where you are deriving child classes from parent classes, there is a chance that the variable names being used by parent or child classes may clash. Python provides a work around to this problem by using the concept of “name mangling”. The “mangling” algorithm works as follows:

  • The class variables which are to be mangled are proceeded by two underscores and at most one trailing underscores. So a mangled variable can be named __var or __var_ but not _var or _var__ or __var__
  • Once the Python interpreter sees a class variable with double underscore, it “mangles” its name to _ClassName.__varName where ClassName is the class to which the variable belongs and __varName is the name of the variable name.

This will be clear from the following example:

In [13]:
class C1:
    x = 'cat'# Normal variable
    __var = 'Dog'# Variable __var is now mangled

c = C1()
print('Normal variable-> ', c.x)
print('Mangled Variable-> ', c._C1__var) #OK
Normal variable->  cat
Mangled Variable->  Dog

13.4. Some common “built in” attributes and methods of a Python mo`dules and classes
13.4.1. __name__
See Page 329 of the book
Classes in Python have attributes. In Python, every module (which is nothing but a .py file) also has an “attribute” called __name__. So __name__ is a “module attribute” and not a class/ object attribute. You know that every module (i.e., every .py file) in Python can either be imported or executed. Following about __name__ are relevant:

  • If a module is being executed, then its __name__ attribute will have a “value of ” __main__.
  • However, if the module is not being executed, but only being imported, then the __name__ attribute of the module will have a value equal to the “name of the module”.
  • It is again emphasized that __name__ is an attribute of a module and not of any class.
  • Note that every module in Python is “automatically” assigned a __name__ attribute.
  • So if you want to know whether a Python module is being imported or executed, just check the value of its __name__ attribute.

The following code on Jupyter clarifies the concept. In this example, the __name__ of module being executed is __main__. But the __name__ attribute of the “re” module, which is being imported is re not __main__

In [14]:
import re
print('__name__ of main module->',__name__)
# The re module is imported, so its __name__ is re
print('__name__ of imported re module->',re.__name__)
__name__ of main module-> __main__
__name__ of imported re module-> re

13.4.2. __module__
Just like the __name__ attribute, __module__ is also an attribute of a Python module and not of a class/ object.
For example, consider the Python module re for regular expressions. You have studied earlier that this module has a method search().
So if you import this search() function and later on want to know, from where it has been imported, you can do so using the __module__ attribute.
This is shown in the following code on Jupyter

In [15]:
# Import the search() function from the re module
from re import search
# Use __module__ attribute to get name of the module of the function
print('search() belongs to module->',search.__module__)
search() belongs to module-> re

13.4.3. __dict__
The__dict__ attribute “automatically provided” attributes of both classes and objects in form of a dictionary and can be accessed by using:

  • ClassName.__dict__ (for getting the key:value pair attributes of a class) and
  • Object_name.__dict__ (for getting the key:value pair attributes of an object)

Again note that the __dict__ attribute can be used on a

  • class name as well as on an
  • object name.

This is clear from the following code:

In [16]:
class A:
    x = 'X'# x is a Class variable
    y = 'Y'# y is also a class variable
    def __init__(self):
        self.w = 'W'

print('__dict__ of Class A',A.__dict__)# A.__dict__ gives namespace of Class A
a = A() # a is an object of type A
print('__dict__ of object a',a.__dict__) #a.__dict__ gives namespace of object a
__dict__ of Class A {'__module__': '__main__', 'x': 'X', 'y': 'Y', '__init__': <function A.__init__ at 0x03C43BB8>, '__dict__': <attribute '__dict__' of 'A' objects>, '__weakref__': <attribute '__weakref__' of 'A' objects>, '__doc__': None}
__dict__ of object a {'w': 'W'}

13.4.4. __doc__
Another common automatically provided attribute to a class/ object/ function is __doc__.
Python provides a way of accessing documentation which is attached to modules, classes and functions. The following script shows this:-

# ---ON IDLE---
>>>import math #math is a module which is part of Python library
>>> math.__doc__
'This module is always available.  It provides access to the\nmathematical functions defined by the C standard.'
>>>

You may create your own Class A which has a method f1() and also create an object of Class A as follows:

In [17]:
class A:
    ''' This is docstring of class A'''
    def f1(self):
        ''' This is doc string of function f1'''

a = A() #Create an object of class A
# .__doc__ can be called on class name or on object or function name
print('Doc string of class A->',A.__doc__) #Call __doc__ on class
print('Doc string of object a->', a.__doc__) # call __doc__ on object
print('Doc string of function f1-> ', A.f1.__doc__) # call __doc__ on function
Doc string of class A->  This is docstring of class A
Doc string of object a->  This is docstring of class A
Doc string of function f1->   This is doc string of function f1

13.4.5.__bases__
In Python, classes also have a __bases__ attribute. (Note __bases__ is an attribute of a class and not of a module).
The __bases__ attribute gives a tuple of references to the super classes (i.e., all the classes from which this class has inherited).
Inheritance is dealt later in the book. However, all the inbuilt data types like int, str, etc. are classes in Python and their base classes can be got using the __bases__ attribute as follows:

# ---ON IDLE---
# ON IDLE
>>> int.__bases__   #int, str, tuple-> All are class whose base class is object
(<class'object'>,)
>>> str.__bases__
(<class'object'>,)
>>> tuple.__bases__
(<class'object'>,)
>>>class A:
       pass
>>> A.__bases__
(<class'object'>,)

Further note that in Python, if no class is specified in the class definition, the class will by default inherit from the object. However, it is possible to explicitly inherit from the object as shown in the following code:

In [18]:
class A:            # class A is implicitly derived from class object
    pass
class B(object):    # class B is explicitly derived from class object
    pass
print("Base class of class A-> ", A.__bases__)
print("Base class of class B-> ", B.__bases__)
Base class of class A->  (<class 'object'>,)
Base class of class B->  (<class 'object'>,)

13.4.6.__del__()
__del__() is the destructor method in Python.

  • In this example a Car class has been created, which has an __init__() and a __del__() class method.
  • The __del__() simply prints ‘Car destroyed’ to tell that it was called.
  • Now you create an instance of car, i.e., an object with label or tag maruti.
  • If you assign this tag, i.e., maruti to another value, there are no more references to the object created. The garbage collector of Python interpreter understands this and starts the destruction process of this object.
  • The way it destroys is that it first checks if the class has any of its own del() methods. If yes this method is called first. Thereafter, the object is deleted from the memory.

The following script shows this:-

In [19]:
class Car:
    def __init__(self, name = 'No name'):
        self.name = name
        print('Car created ->', self.name)
    
    def __del__(self):
        print('Car destroyed-> ', self.name)

#... Create object with 1 reference 
maruti = Car('Maruti') # Object maruti of type Car created
maruti = 10#maruti does not refer to object of type Car anymore-> destroyed
Car created -> Maruti
Car destroyed->  Maruti

However if you create two references to the same object and then remove one of the two references, then the Garbage collector will not call the __del__() method. The GC waits for the second reference to be removed and then calls the GC as shown in the following code:

In [20]:
class Car:
    def __init__(self, name = 'No name'):
        self.name = name
        print('Car created ->', self.name)

    def __del__(self):
        print('Car destroyed-> ', self.name)

# Create a Car object
myCar = Car('Ford')
# Create an alias to Car object
aliasCar = myCar
# Destroy first car object
del myCar                   # Will not call __del__()
print('myCar destroyed but __del__() not called so far')
# Destroy alias to Car object
del aliasCar                # Will call __del__()
Car created -> Ford
myCar destroyed but __del__() not called so far
Car destroyed->  Ford

13.4.7. some_object.__str__()
See Page 335 of the book

  • In the statement some_object.__str__(), some_object represents some Python object.
  • Here __str__() is the “string representation” of the given object. object.__str__(self)
  • What does informal string representation of an object mean?
  • This is a human readable string which a programmer creating a class can write and make available to the user of the class using the __str__() member method from inside the class.
  • The user of the class can then use the str(object) function, where object is the object whose string representation is desired. Note that the Python built-in function str(some_object), in turn calls some_object.__str__().
    This will become clear from the following examples. The following code creates a Dog class which does not have any __str__() method.

This is as follows:

In [21]:
class Dog:
    def __init__(self, legs =4, color = 'Black'):
        self.legs = legs
        self.color = color
tommy = Dog()#tommy is an instance of Dog class
print(str(tommy))# Same as print(tommy)
print(tommy) # String representation of object tommy will be printed
<__main__.Dog object at 0x03B7C7D0>
<__main__.Dog object at 0x03B7C7D0>

Now you may create the same Dog class but with a __str__() method. This is shown in the following code:

In [22]:
class Dog:
    def __init__(self, legs =4, color = 'Black'):
        self.legs = legs
        self.color = color

    def __str__(self):
        return 'Object of Dog class'#String representation of object of class Dog

tommy = Dog()  # tommy is an instance of Dog class
print(str(tommy))  # Same as print(tommy)
print(tommy)  # String representation of object tommy will be printed
Object of Dog class
Object of Dog class

13.7. Exercise
See Page 338 of the book
2. Create a class Animal. This class should have a class variable animal_type and it should also have an object variable animal_type. This is to say that both class variable and object (i.e., instance of class) should have same variable name.
Solution:-

In [23]:
class Animal:
    animal_type = 'class animal' #  class variable
    def __init__(self, animal_type = 'object animal'):
        self.animal_type = animal_type  # object variable

# animal1 is instance ie object of Animal class
animal1 = Animal()
print('class animal_type->', Animal.animal_type)
print('object animal_type->', animal1.animal_type)
class animal_type-> class animal
object animal_type-> object animal

c. Find and write the output of the following Python code:

In [24]:
class Emp:
    def __init__(self, code, nm): # Constructor
        self.Code = code
        self.Name = nm

    def manip(self):
        self.Code = self.Code + 10
        self.Name = 'Karan'

    def show(self, line):
        print(self.Code, self.Name, line)

s = Emp(25, 'Mamta')
s.show(1)
s.show(2)

print(s.Code + len(s.Name))
25 Mamta 1
25 Mamta 2
30

13.8. Beyond text book
See Page 339 of the book
a. In the chapter the topic string representation of an object of an object was covered. It is possible to get the string representation of an object by implementing the __str__() method in the class definition. But Python provides another such method called ,the __repr__() method. The difference between the two is subtle (slight) but important.

  • __repr__() is called by inbuilt function repr(Object_name) and gives the “official string representation”, while __str__() when called by inbuilt function str() gives the “informal string representation”.
  • However, if a class implements the __repr__() method but does not implement the __str__(), method, then calling the inbuilt str() function will give the string of the __repr__() method of the class. This can be best understood by the following example:
In [25]:
# Dog class does not have __str__() method
# But Dog class has a __repr__() method
class Dog:
    def __init__(self, legs =4, color = 'Black'):
        self.legs = legs
        self.color = color

    def __repr__(self):
        return 'Object of Dog class (From __repr__())'

tommy = Dog()#tommy is an instance of Dog class
# There is no __str__() method in Dog class
# So the str() function will use the __repr__() method of Dog class
print(str(tommy))# Same as print(tommy)
print(tommy) # String representation of object tommy will be printed
Object of Dog class (From __repr__())
Object of Dog class (From __repr__())

b. Decorators in Python:
This topic is not covered in the book
In the chapter there was a brief discussion on one decorator namely @staticmethod. But “decorators” were not explained in details.
Note that there are two types of decorators in Python, i.e., “in-built” and “user-defined”. To understand the concept, you may create “your own decorator”.
Have a look at the following code:

In [26]:
# decorate in 2 different ways
def some_func():
    print('An ordinary function')

# A function which takes another function name as parameter
def my_decorator_func(a_func):
    def nested_func():
        print('Decorate->')
        # Now call the function to be decorated
        a_func()
    return nested_func

# Method 1 of decoration
decorated_func = my_decorator_func(some_func)
decorated_func()

# Method 2 of decoration
@my_decorator_func
def another_func():
    print('Another ordinary but decorated function')

another_func()
Decorate->
An ordinary function
Decorate->
Another ordinary but decorated function

c. The garbage collector (gc) module
This topic is not covered in the book

  • It was mentioned in the text that Python has an “automatic garbage collector”.
  • This garbage collector works on “reference counting” mechanism.
  • Any variable name in Python is not an object in itself but rather it “points to” an object or is a “reference” to an object.
  • The number of variable names referring to a particular object is the “reference count” for that object.
  • So when an object has no variable referring to it, then its reference count becomes 0 and the garbage collector can then remove it.
  • The reference count to an object can increase by following:
    • Using the assignment operator.
    • Passing the object as an argument (for example, passing an object as an argument to a function or a class).
  • Appending the object to some container (for example if an object is added to a list, its reference count will increase).
  • One can always check the reference count of an object by using the sys.getrefcount() function of the sys module.

You can check out the docstring of getrefcount() on Jupyter as follows:

# ---ON IDLE---
import sys
?sys.getrefcount()

The following script shows how the getrefcount() function is used (There will be an error):

In [27]:
import sys
L = [1, 2, 3]
print('ref counts of L->', sys.getrefcount(L))  # refcount is 2
L2 = L
print('ref counts of L->', sys.getrefcount(L))  # refcount is 3
del L2
print('ref counts of L->', sys.getrefcount(L))  # refcount is 2
del L  
print('ref counts of L->', sys.getrefcount(L))  # Error L doesnt exist any more
ref counts of L-> 2
ref counts of L-> 3
ref counts of L-> 2
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-27-eaf5365ed693> in <module>()
      7 print('ref counts of L->', sys.getrefcount(L))  # refcount is 2
      8 del L
----> 9 print('ref counts of L->', sys.getrefcount(L))  # Error L doesnt exist any more

NameError: name 'L' is not defined

While the “reference count” garbage collector cannot be controlled by a programmer, it is possible to control the generational gc. Python provides a module called gc for this purpose and through this module one can get “access” to the generational garbage collector. You can check the details of all the functions of this module gc on Jupyter as shown in the following code:

# ---ON IDLE---
import gc
?gc

The output (Truncated and modified) is

# ---ON IDLE---
# OUTPUT (Truncated and modified)
Type:        module
String form: <module 'gc' (built-in)>
Docstring:  
This module provides access to the garbage collector for reference cycles.

enable() -- Enable automatic garbage collection.
disable() -- Disable automatic garbage collection.
isenabled() -- Returns true if automatic collection is enabled.
collect() -- Do a full collection right now.
get_count() -- Return the current collection counts.
get_stats() -- Return list of dictionaries containing per-generation stats.
set_debug() -- Set debugging flags.
get_debug() -- Get debugging flags.
set_threshold() -- Set the collection thresholds.
get_threshold() -- Return the current the collection thresholds.
get_objects() -- Return a list of all objects tracked by the collector.
is_tracked() -- Returns true if a given object is tracked.
get_referrers() -- Return the list of objects that refer to an object.
get_referents() -- Return the list of objects that an object refers to.

From above you can see that the gc module can be used in a number of ways to modify the behavior of the generational garbage collector. You can even stop the generational gc using disable() or start it using enable() or run it even when the “threshold” has not been reached by using gc.collect(). The following code shows how you can get the threshold values for the three generations, get the number of objects in each generation and use the collect() function:

In [28]:
import gc
print('default threshold values->', gc.get_threshold())
print('current objects in each generation->', gc.get_count())
print('Use collect()->', gc.collect())
print('After collect() objects in each generation->', gc.get_count())
default threshold values-> (700, 10, 10)
current objects in each generation-> (335, 5, 8)
Use collect()-> 19
After collect() objects in each generation-> (6, 0, 0)

d. Using the ctypes “foreign library” to detect “cyclic references” in Python.
This topic is not covered in the book
If you delete an object yet if it is still present in memory, i.e., it is not collected by the garbage collector, then how does one know about this?
Following is a script which shows that cyclic references lead to objects existing in the memory even after destruction. The script works as follows:

  • A list is created which appends itself to it. So in effect the list refers to itself thereby creating a cyclic reference. Then its “address” in memory is got by using the id() inbuilt function. This address will be used to locate the object with cyclic reference in the memory.
  • A class named RefCounter is sub-classed from the Structure base class of the ctypes module.
  • This class, i.e., RefClass has an attribute _fields_. You must be careful in defining this attribute because it has to be a list of 2 tuples. The first item in the tuple is the “name” of the field and you can chose any arbitrary name. Here the name “ref_cnt” has been chosen. But the second item in the tuple must be a valid ctypes data. Here ctypes.c_long has been chosen.
  • The Structure base class (from which the class RefCounter was sub-classed) has a method from_address(some_address). This method provides “a C instance at the specified address”. You can check out this method by using the following on Jupyter:
# ---ON IDLE---
import ctypes
?ctypes.Structure.from_address

The output (Truncated and modified) is

# ---ON IDLE---
# OUTPUT (Truncated and modified)
Docstring:
C.from_address(integer) -> C instance
access a C instance at the specified address
Type:      builtin_function_or_method
  • For the from_address() method, you must use the address of the object (which you got using the id() function).
  • You can then use the fields attribute with value ref_counts, to get the number of references to the object even after it has been destroyed.
In [29]:
import ctypes
class RefCounter(ctypes.Structure):
    # ctypes.Structure is an abstract class, so
    # the _fields_ attribute must be specified
    _fields_ = [('ref_cnt', ctypes.c_long)]

# Create a list
L = [1, 2, 3]
# Append the list to itself. So cyclic reference created
L.append(L)
# Get address of L
L_address = id(L)
print(L_address)
# Get memory location of the C object
c_obj = RefCounter.from_address(L_address)
print(c_obj)
# Use the ref_cnt created earlier using _fields_ attribute of RefCounter class
ref_counts = c_obj.ref_cnt

print('references to L before destruction->',ref_counts)
del L
# Now you cannot access L from the script
ref_counts = c_obj.ref_cnt
print('references to L after destruction->',ref_counts)
63285688
<__main__.RefCounter object at 0x03C090D0>
references to L before destruction-> 2
references to L after destruction-> 1